查看原文
其他

Android 图文混排富文本编辑器实现详解

ljzdyh 鸿洋 2019-04-05

本文作者


作者:ljzdyh

链接:

https://blog.csdn.net/ljzdyh/article/details/82497625

本文由作者授权发布。


1概述


需求:

android 实现富文本编辑器,并且实现html解析和生成。


功能点:


  1. 字体加粗,斜体,下划线,删除线

  2. 字体设置大小   默认大(18px),中(16px),小(14px)

  3. 字体设置颜色

  4. 换行插入图片

  5. 编辑内容生成html

  6. 解析html并且显示


主要实现方式


  • EditText + Span 的实现方式

  • WebView + JavaScript 的实现方式


webview方式存在兼容性问题,所以还是得走原生路线。EditText + Span。


2知识储备



span是设置 EditText 内容效果的 对象,是内容表达的载体;span派生类有StyleSpan(加粗斜体),UnderlineSpan(下划线),StrikethroughSpan(删除线)等等。


Android中各种Span的用法

https://blog.csdn.net/qq_16430735/article/details/50427978


Spanable中的常用常量:


  • Spanned.SPAN_EXCLUSIVE_EXCLUSIVE --- 不包含start和end所在的端点                 (a,b)

  • Spanned.SPAN_EXCLUSIVE_INCLUSIVE --- 不包含端start,但包含end所在的端点       (a,b]

  • Spanned.SPAN_INCLUSIVE_EXCLUSIVE --- 包含start,但不包含end所在的端点          [a,b)

  • Spanned.SPAN_INCLUSIVE_INCLUSIVE--- 包含start和end所在的端点                       [a,b]


了解了大概之后,就开始写代码;


1.定义FontStyle 字体样式基类,定义初始化Span方法


private <T> void setSpan(FontStyle fontStyle,boolean isSet,Class<T> tClass){
    Log.d("setSpan","");
    int start = getSelectionStart();
    int end = getSelectionEnd();
    int mode = EXCLUD_INCLUD_MODE;
    T[] spans = getEditableText().getSpans(start,end,tClass);
    //获取
    List<SpanPart> spanStyles = getOldFontSytles(spans,fontStyle);
    for(SpanPart spanStyle : spanStyles){
        if(spanStyle.start<start){
            if(start==end){mode=EXCLUD_MODE;}
            getEditableText().setSpan(getInitSpan(spanStyle), spanStyle.start,start,mode);
        }
        if(spanStyle.end>end){
            getEditableText().setSpan(getInitSpan(spanStyle),end, spanStyle.end,mode);
        }
    }
    if(isSet){
        if(start==end){
            mode=INCLUD_INCLUD_MODE;
        }
        getEditableText().setSpan(getInitSpan(fontStyle),start,end,mode);
    }
}


用到的辅助方法:


private CharacterStyle getInitSpan(FontStyle fontStyle){
    if(fontStyle.isBold){
        return new StyleSpan(Typeface.BOLD);
    }else if(fontStyle.isItalic){
        return new StyleSpan(Typeface.ITALIC);
    }else if(fontStyle.isUnderline){
        return new UnderlineSpan();
    }else if(fontStyle.isStreak){
        return new StrikethroughSpan();
    }else if(fontStyle.fontSize>0){
        return new AbsoluteSizeSpan(fontStyle.fontSize,true);
    }else if(fontStyle.color!=0){
        return new ForegroundColorSpan(fontStyle.color);
    }
    return  null;
}


private <T> List<SpanPart> getOldFontSytles(T[] spans, FontStyle fontStyle){
    List<SpanPart> spanStyles = new ArrayList<>();
    for(T span:spans){
        boolean isRemove=false;
        if(span instanceof StyleSpan){//特殊处理 styleSpan
            int style_type = ((StyleSpan) span).getStyle();
            if((fontStyle.isBold&& style_type== Typeface.BOLD)
                    || (fontStyle.isItalic&&style_type== Typeface.ITALIC)){
                isRemove=true;
            }
        }else{
            isRemove=true;
        }
        if(isRemove) {
            SpanPart spanStyle = new SpanPart(fontStyle);
            spanStyle.start = getEditableText().getSpanStart(span);
            spanStyle.end = getEditableText().getSpanEnd(span);
            if(span instanceof AbsoluteSizeSpan){
                spanStyle.fontSize = ((AbsoluteSizeSpan) span).getSize();
            }else if(span instanceof ForegroundColorSpan){
                spanStyle.color = ((ForegroundColorSpan) span).getForegroundColor();
            }
            spanStyles.add(spanStyle);
            getEditableText().removeSpan(span);
        }
    }
    return spanStyles;
}


setSpan 是公共设置样式方法,通过fontStyle传参,设置对应的样式,例如设置加粗和斜体:


private void setStyleSpan(boolean isSet,int type){
    FontStyle fontStyle = new FontStyle();
    if(type== Typeface.BOLD){
        fontStyle.isBold=true;
    }else if(type== Typeface.ITALIC){
        fontStyle.isItalic=true;
    }
    setSpan(fontStyle,isSet,StyleSpan.class);
}


setSpan处理思路:


  1. 获取当前选中位置position,在该位置是否已经设置了 需要处理样式,如 加粗;

  2. 如果有,在getOldFontSytles 方法中,会进行判断移除;(因为假如选中位置有加粗,再设置一次就是取消)

  3. span设置样式和 html 类似,是通过始末设tag来控制区间样式的,所以,你选中区间样式CD,可能与原有样式区间AB是包含,交集关系。因此,当你移除旧样式的时候,需要补始末的tag,这样才能保持未选中的区间样式不变。代码getOldFontSytles后for 循环执行补tag 逻辑。

  4. 当非选中状态下,即光标移至某处,设置字体样式,随后输入的文字都是当前设置样式,需要判断start =end ,然后变更span设置mode 方式。需要使用SPAN_INCLUSIVE_INCLUSIVE。


加粗斜体效果



2.插入图片


设置图片,需要用到ImageSpan  ImageSpan(Context context, Bitmap b)   通过重定义RichImageSpan 继承 ImageSpan 同时重写getSource方法,赋值uri 这样利用Glide管理bitmap,防止内存溢出。(\nimg\n 是为了让图片占位,可以自行设置别的,没有要求)


public class RichImageSpan extends ImageSpan {
    private Uri mUri;
    public RichImageSpan(Context context, Bitmap b, Uri uri) {
        super(context, b);
        mUri = uri;
    }
    @Override
    public String getSource() {
        return mUri.toString();
    }

/**
 * 图片加载
 * @param path
 */

public void image(String path) {
    final Uri uri = Uri.parse(path);
    final int maxWidth = view.getMeasuredWidth() -view. getPaddingLeft() - view.getPaddingRight();
    RequestOptions options = new RequestOptions()
            .centerCrop()
            .placeholder(R.mipmap.ic_launcher)
            .error(R.mipmap.ic_launcher);
    glideRequests.asBitmap()
            .load(new File(path))
            .apply(options)
            .into(new SimpleTarget<Bitmap>() {
                @Override
                public void onResourceReady(Bitmap resource, Transition<? super Bitmap> transition) {
                    Bitmap bitmap = zoomBitmapToFixWidth(resource, maxWidth);
                    image(uri, bitmap);
                }
            });
}

public void image(Uri uri, Bitmap pic) {
    String img_str="img";
    int start = view.getSelectionStart();
    SpannableString ss = new SpannableString("\nimg\n");
    RichImageSpan myImgSpan = new RichImageSpan(mContext, pic, uri);
    ss.setSpan(myImgSpan, 1, img_str.length()+1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    view.getEditableText().insert(start, ss);// 设置ss要添加的位置
    view.requestLayout();
    view.requestFocus();
}


插入图片效果



3.span生成html


目前原生 hmtl  能够支持进行html 解析,但是想做定制化的解析,需要对其进行修改。拷贝一份Html.java 为CustomHtml.java;


查看源码得知,html 将span 转化 html 是通过 withinParagraph方法,遍历当前控件样式CharacterStyle 数组,然后根据对应样式,加入对应css 标签(现在主流是style 方式, 目前我只是简单使用了常规html标签做样式控制,可以改)。


部分核心代码如下:


private static void withinParagraph(StringBuilder out, Spanned text, int start, int end) {
    int next;
    for (int i = start; i < end; i = next) {
        next = text.nextSpanTransition(i, end, CharacterStyle.class);
        CharacterStyle[] style = text.getSpans(i, next, CharacterStyle.class);
        AbsoluteSizeSpan tmp_rel_span = null;
        ForegroundColorSpan tmp_fColor_span =null;
        for (int j = 0; j < style.length; j++) {
            if (style[j] instanceof StyleSpan) {
                int s = ((StyleSpan) style[j]).getStyle();

                if ((s & Typeface.BOLD) != 0) {
                    out.append("<b>");
                }
                if ((s & Typeface.ITALIC) != 0) {
                    out.append("<i>");
                }
            }
            if (style[j] instanceof TypefaceSpan) {
                String s = ((TypefaceSpan) style[j]).getFamily();

                if ("monospace".equals(s)) {
                    out.append("<tt>");
                }
            }
            if (style[j] instanceof SuperscriptSpan) {
                out.append("<sup>");
            }
            if (style[j] instanceof SubscriptSpan) {
                out.append("<sub>");
            }
            if (style[j] instanceof UnderlineSpan) {
                out.append("<u>");
            }
            if (style[j] instanceof StrikethroughSpan) {
                out.append("<strike>");
            }
            if (style[j] instanceof URLSpan) {
                out.append("<a href=\"");
                out.append(((URLSpan) style[j]).getURL());
                out.append("\">");
            }
            if (style[j] instanceof ImageSpan) {
                out.append("<img src=\"");
                out.append(((ImageSpan) style[j]).getSource());
                out.append("\">");

                // Don't output the dummy character underlying the image.
                i = next;
            }
            if (style[j] instanceof AbsoluteSizeSpan) {
                tmp_rel_span= ((AbsoluteSizeSpan) style[j]);
            }
            if (style[j] instanceof RelativeSizeSpan) {
                float sizeEm = ((RelativeSizeSpan) style[j]).getSizeChange();
                out.append(String.format("<span style=\"font-size:%.2fem;\">", sizeEm));
            }
            if (style[j] instanceof ForegroundColorSpan) {
                tmp_fColor_span = ((ForegroundColorSpan) style[j]);
            }
            if (style[j] instanceof BackgroundColorSpan) {
                int color = ((BackgroundColorSpan) style[j]).getBackgroundColor();
                out.append(String.format("<span style=\"background-color:#%06X;\">",
                        0xFFFFFF & color));
            }
        }
        //处理字体 颜色
        StringBuilder style_font = new StringBuilder();
        if(tmp_fColor_span!=null||tmp_rel_span!=null){
            style_font.append("<font ");
        }
        //颜色
        if(tmp_fColor_span!=null){
            style_font.append(String.format("color='#%06X' "0xFFFFFF &  tmp_fColor_span.getForegroundColor()));
        }
        //字体
        if(tmp_rel_span!=null){
            String value = "16px";
            if(tmp_rel_span.getSize()== FontStyle.BIG){
                value="18px";
            }else if(tmp_rel_span.getSize()==FontStyle.SMALL){
                value="14px";
            }
            style_font.append("style='font-size:"+value+";'");
        }
        if(style_font.length()>0){
            out.append(style_font+">");
        }
        withinStyle(out, text, i, next);
        if(style_font.length()>0){
            out.append("</font>");
        }
        for (int j = style.length - 1; j >= 0; j--) {
            if (style[j] instanceof BackgroundColorSpan) {
                out.append("</span>");
            }
            if (style[j] instanceof RelativeSizeSpan) {
                out.append("</span>");
            }
            if (style[j] instanceof URLSpan) {
                out.append("</a>");
            }
            if (style[j] instanceof StrikethroughSpan) {
                out.append("</strike>");
            }
            if (style[j] instanceof UnderlineSpan) {
                out.append("</u>");
            }
            if (style[j] instanceof SubscriptSpan) {
                out.append("</sub>");
            }
            if (style[j] instanceof SuperscriptSpan) {
                out.append("</sup>");
            }
            if (style[j] instanceof TypefaceSpan) {
                String s = ((TypefaceSpan) style[j]).getFamily();

                if (s.equals("monospace")) {
                    out.append("</tt>");
                }
            }
            if (style[j] instanceof StyleSpan) {
                int s = ((StyleSpan) style[j]).getStyle();

                if ((s & Typeface.BOLD) != 0) {
                    out.append("</b>");
                }
                if ((s & Typeface.ITALIC) != 0) {
                    out.append("</i>");
                }
            }
        }
    }
}


接下来我们就刚刚gif 输入内容生成html看看效果:


copy出来在W3School上看显示效果:



p.s.图片显示不出,因为路径是手机本地,若需要,应当在转html时,先上传获得图片url,在赋值转html。


4. html 转 span 


转换核心在于 CustomHtmlToSpannedConverter类,它通过识别html的标签 然后对应处理 生成span;我主要处理了handleStartTag ,handleEndTag 方法,增加了图片处理通过继承 ImageGetter (网上一般处理方法)重写getDrawable。


private void handleStartTag(String tag, Attributes attributes) {
    if (tag.equalsIgnoreCase("br")) {
        // We don't need to handle this. TagSoup will ensure that there's a </br> for each <br>
        // so we can safely emit the linebreaks when we handle the close tag.
    } else if (tag.equalsIgnoreCase("p")) {
        startBlockElement(mSpannableStringBuilder, attributes, getMarginParagraph());
        startCssStyle(mSpannableStringBuilder, attributes);
    } else if (tag.equalsIgnoreCase("ul")) {
        startBlockElement(mSpannableStringBuilder, attributes, getMarginList());
    } else if (tag.equalsIgnoreCase("li")) {
        startLi(mSpannableStringBuilder, attributes);
    } else if (tag.equalsIgnoreCase("div")) {
        startBlockElement(mSpannableStringBuilder, attributes, getMarginDiv());
    } else if (tag.equalsIgnoreCase("span")) {
        startCssStyle(mSpannableStringBuilder, attributes);
    } else if (tag.equalsIgnoreCase("strong")) {
        start(mSpannableStringBuilder, new Bold());
    } else if (tag.equalsIgnoreCase("b")) {
        start(mSpannableStringBuilder, new Bold());
    } else if (tag.equalsIgnoreCase("em")) {
        start(mSpannableStringBuilder, new Italic());
    } else if (tag.equalsIgnoreCase("cite")) {
        start(mSpannableStringBuilder, new Italic());
    } else if (tag.equalsIgnoreCase("dfn")) {
        start(mSpannableStringBuilder, new Italic());
    } else if (tag.equalsIgnoreCase("i")) {
        start(mSpannableStringBuilder, new Italic());
    } else if (tag.equalsIgnoreCase("big")) {
        start(mSpannableStringBuilder, new Big());
    } else if (tag.equalsIgnoreCase("small")) {
        start(mSpannableStringBuilder, new Small());
    } else if (tag.equalsIgnoreCase("font")) {
        startFont(mSpannableStringBuilder, attributes);
    } else if (tag.equalsIgnoreCase("blockquote")) {
        startBlockquote(mSpannableStringBuilder, attributes);
    } else if (tag.equalsIgnoreCase("tt")) {
        start(mSpannableStringBuilder, new Monospace());
    } else if (tag.equalsIgnoreCase("a")) {
        startA(mSpannableStringBuilder, attributes);
    } else if (tag.equalsIgnoreCase("u")) {
        start(mSpannableStringBuilder, new Underline());
    } else if (tag.equalsIgnoreCase("del")) {
        start(mSpannableStringBuilder, new Strikethrough());
    } else if (tag.equalsIgnoreCase("s")) {
        start(mSpannableStringBuilder, new Strikethrough());
    } else if (tag.equalsIgnoreCase("strike")) {
        start(mSpannableStringBuilder, new Strikethrough());
    } else if (tag.equalsIgnoreCase("sup")) {
        start(mSpannableStringBuilder, new Super());
    } else if (tag.equalsIgnoreCase("sub")) {
        start(mSpannableStringBuilder, new Sub());
    } else if (tag.length() == 2 &&
            Character.toLowerCase(tag.charAt(0)) == 'h' &&
            tag.charAt(1) >= '1' && tag.charAt(1) <= '6') {
        startHeading(mSpannableStringBuilder, attributes, tag.charAt(1) - '1');
    } else if (tag.equalsIgnoreCase("img")) {
        startImg(mSpannableStringBuilder, attributes, mImageGetter);
    } else if (mTagHandler != null) {
        mTagHandler.handleTag(true, tag, mSpannableStringBuilder, mReader);
    }
}


如上代码所示,可以根据自己定义的协议,修改对应tag标签处理。


总体效果图:




已上传github,喜欢的朋友,可以收藏给个心;

https://github.com/awarmisland/RichEditText




项目非常有学习意义,尤其是对 Span 的实战。


不过,对于一些简单的图文混排可以考虑使用自定义的方式,但是如果考虑三端统一,尤其是支持 PC 上编辑文章,移动端显示的,最好的方式还是去使用webview,主要是 PC 上的编辑器会插入非常多复杂的 html 标签,非常难解析。当然了 webview 自带很多兼容性问题,选择开源项目,一定要提前查看issue,避免最后踩坑,我之前就遇到过类似囧境(低版本无法删除img标签,最终通过调用 js 删除img)。



推荐阅读

如何封装个好用的高斯模糊组件

分享一个困惑了我很久的知识点 | Exif


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存